/*
 *  This file is part of the Image Metadata Editor.
 *
 *  Copyright (C) 2015 Australian Institute of Marine Science
 *
 *  Contact: Gael Lafond <g.lafond@aims.org.au>
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package au.gov.aims.exif.editor;

import org.apache.commons.imaging.ImageReadException;
import org.apache.commons.imaging.ImageWriteException;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.common.ImageMetadata;
import org.apache.commons.imaging.common.RationalNumber;
import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata;
import org.apache.commons.imaging.formats.jpeg.JpegPhotoshopMetadata;
import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter;
import org.apache.commons.imaging.formats.jpeg.iptc.IptcBlock;
import org.apache.commons.imaging.formats.jpeg.iptc.IptcRecord;
import org.apache.commons.imaging.formats.jpeg.iptc.IptcType;
import org.apache.commons.imaging.formats.jpeg.iptc.JpegIptcRewriter;
import org.apache.commons.imaging.formats.jpeg.iptc.PhotoshopApp13Data;
import org.apache.commons.imaging.formats.tiff.TiffContents;
import org.apache.commons.imaging.formats.tiff.TiffDirectory;
import org.apache.commons.imaging.formats.tiff.TiffField;
import org.apache.commons.imaging.formats.tiff.TiffHeader;
import org.apache.commons.imaging.formats.tiff.TiffImageMetadata;
import org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.TiffDirectoryConstants;
import org.apache.commons.imaging.formats.tiff.taginfos.TagInfo;
import org.apache.commons.imaging.formats.tiff.taginfos.TagInfoAscii;
import org.apache.commons.imaging.formats.tiff.taginfos.TagInfoRational;
import org.apache.commons.imaging.formats.tiff.taginfos.TagInfoXpString;
import org.apache.commons.imaging.formats.tiff.write.TiffOutputDirectory;
import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

// TODO This class should not use the NotificationWindow, it should throw exceptions that are catch by the CSVEditor and ImageEditor to display appropriate error message.

// http://commons.apache.org/proper/commons-imaging/xref-test/org/apache/commons/imaging/examples/WriteExifMetadataExample.html
// http://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/EXIF.html
public class ExifWrapper {
	private static final Logger LOGGER = Logger.getLogger(ExifWrapper.class.getName());

	// Limit size of a metadata block (EXIF, IPTC, etc.)
	// NOTE: Each EXIF fields has a variable absolute offset. It's very hard to determine that offset so an artificial padding is used instead.
	// See: http://www.media.mit.edu/pia/Research/deepview/exif.html#TiffHeader
	public static final int APPN_BYTE_SIZE_LIMIT = 65536;
	public static final int BYTE_PADDING_BETWEEN_FIELDS = 100;

	// The calculation of the EXIF header size is not reliable (it only show a warning when the size is way
	// above the limit) and the exception thrown by the imaging library are not constant, so we need an other
	// way to warn the user when it goes over the limit.
	// Solution: if there is an exception while saving the file AND at lease 1 APPN size is above the following
	// threshold, we show a warning to the user telling there is good chance that the size limit has been reached.
	public static final int WARNING_LIMIT_BYTE_SIZE = 32768;

	public static final int LONGITUDE_IND = 0;
	public static final int LATITUDE_IND = 1;

	private File imageFile;
	private JpegImageMetadata jpegMetadata;

	// The image metadata is read-only. The values can only be set to its new value while creating the output file.
	private Map<TagInfo, String> modifiedExif;
	private Map<IptcType, String> modifiedIptc;
	private Double[] gps;


	public ExifWrapper(File imageFile) {
		this.imageFile = imageFile;
		this.reload();
	}

	public File getImageFile() {
		return this.imageFile;
	}

	public static boolean isSupportedImage(File file) {
		if (file == null || !file.exists() || !file.canRead()) {
			return false;
		}
		try {
			MagicNumber magicNumber = MagicNumber.getMagicNumber(file);
			// Only JPEG are supported for now.
			return MagicNumber.JPG.equals(magicNumber);
		} catch(Exception ex) {
			LOGGER.log(Level.WARNING, "Can not read file magic number: " + file.getAbsolutePath());
		}
		return false;
	}

	public boolean isEmpty() {
		return this.jpegMetadata == null;
	}

	/**
	 * Approximate the disk space used by the EXIF header, to know if the data can be save on the file.
	 * NOTE: This value is very inaccurate. A better understanding of EXIF padding / offset is needed to
	 *     give a more accurate value.
	 * @return A crude approximation of the EXIF header size, in bytes.
	 * @throws UnsupportedEncodingException
	 */
	public long getExifSize() throws UnsupportedEncodingException {
		long size = 0;

		// EXIF has field offset, like a padding to starts each fields at the beginning of a block.
		// Those value should not be ignored.
		// See: http://www.media.mit.edu/pia/Research/deepview/exif.html#TiffHeader
		if (this.jpegMetadata != null) {
			TiffImageMetadata exif = this.jpegMetadata.getExif();
			if (exif != null) {
				List<TiffField> fields = exif.getAllFields();
				if (fields != null && !fields.isEmpty()) {
					for (TiffField field : fields) {
						if (this.modifiedExif != null && !this.modifiedExif.containsKey(field.getTagInfo())) {
							byte[] value = field.getByteArrayValue();
							if (value != null && value.length > 0) {
								size += value.length + BYTE_PADDING_BETWEEN_FIELDS;
							}
						}
					}
				}
			}
		}
		if (this.modifiedExif != null) {
			for (Map.Entry<TagInfo, String> mapEntry : this.modifiedExif.entrySet()) {
				if (mapEntry != null) {
					TagInfo tag = mapEntry.getKey();
					String val = mapEntry.getValue();
					if (tag != null && !StringUtils.isEmpty(val)) {
						if (tag instanceof TagInfoXpString) {
							size += val.getBytes("UNICODE").length;
						} else {
							// Otherwise it's safe to assume it's an ASCII field
							size += UnicodeUtils.toASCII(val).length();
						}
						size += BYTE_PADDING_BETWEEN_FIELDS;
					}
				}
			}
		}

		return size;
	}

	/**
	 * Approximate the disk space used by the IPTC header, to know if the data can be save on the file.
	 * @return A crude approximation of the IPTC header size, in bytes.
	 */
	public long getIptcSize() {
		long size = 0;

		if (this.jpegMetadata != null) {
			JpegPhotoshopMetadata photoshop = this.jpegMetadata.getPhotoshop();
			if (photoshop != null && photoshop.photoshopApp13Data != null) {
				List<IptcRecord> records = photoshop.photoshopApp13Data.getRecords();
				if (records != null && !records.isEmpty()) {
					for (IptcRecord field : records) {
						if (this.modifiedIptc != null && !this.modifiedIptc.containsKey(field.iptcType)) {
							byte[] value = field.getRawBytes();
							if (value != null && value.length > 0) {
								size += value.length;
							}
						}
					}
				}
			}
		}
		if (this.modifiedIptc != null) {
			for (Map.Entry<IptcType, String> mapEntry : this.modifiedIptc.entrySet()) {
				if (mapEntry != null) {
					String val = mapEntry.getValue();
					if (!StringUtils.isEmpty(val)) {
						// The value has already been changed to ISO 8859-1, the length represent the file size used
						size += val.length();
					}
				}
			}
		}

		return size;
	}

	public void reload() {
		this.modifiedExif = new HashMap<TagInfo, String>();
		this.modifiedIptc = new HashMap<IptcType, String>();
		this.gps = null;
		this.jpegMetadata = null;

		if (this.imageFile != null) {
			if (!this.imageFile.exists()) {
				FXUtils.getNotificationWindow().showError("The file ["+this.imageFile.getAbsolutePath()+"] do not exists.");
				LOGGER.log(Level.SEVERE, "The file ["+this.imageFile.getAbsolutePath()+"] do not exists.");
			} else if (!this.imageFile.canRead()) {
				FXUtils.getNotificationWindow().showError("The file ["+this.imageFile.getAbsolutePath()+"] is not readable.");
				LOGGER.log(Level.SEVERE, "The file ["+this.imageFile.getAbsolutePath()+"] is not readable.");
			} else if (!this.imageFile.canWrite()) {
				FXUtils.getNotificationWindow().showWarning("The file ["+this.imageFile.getAbsolutePath()+"] is readonly.");
				LOGGER.log(Level.SEVERE, "The file ["+this.imageFile.getAbsolutePath()+"] is readonly.");
			}

			// http://commons.apache.org/proper/commons-imaging/
			// https://svn.apache.org/repos/asf/commons/proper/imaging/trunk/src/test/java/org/apache/commons/imaging/examples/WriteExifMetadataExample.java
			try {
				ImageMetadata rawMetadata = Imaging.getMetadata(this.imageFile);
				if (rawMetadata instanceof JpegImageMetadata) {
					this.jpegMetadata = (JpegImageMetadata)rawMetadata;
				}
			} catch(Exception ex) {
				this.jpegMetadata = null;
				LOGGER.log(Level.FINEST, "Can not load the file ["+this.imageFile.getAbsolutePath()+"]", ex);
			}

			// Some images do not have metadata. That doesn't stop us from creating one.
			if (this.jpegMetadata == null) {
				if (this.imageFile != null && this.imageFile.exists() && this.imageFile.canRead()) {
					try {
						// Get the file magic number
						MagicNumber magicNumber = MagicNumber.getMagicNumber(this.imageFile);
						if (MagicNumber.JPG.equals(magicNumber)) {
							this.jpegMetadata = createJPEGMetadata();
						} else {
							LOGGER.log(Level.INFO, "Unsupported file magic number {0}", this.imageFile.getAbsolutePath());
						}
					} catch(Exception ex) {
						LOGGER.log(Level.WARNING, "Can not extract the magic number of the file " + this.imageFile.getAbsolutePath(), ex);
					}
				}
			}
		}
	}

	private static JpegImageMetadata createJPEGMetadata() {
		return new JpegImageMetadata(
				new JpegPhotoshopMetadata(new PhotoshopApp13Data(
						new ArrayList<IptcRecord>(),
						new ArrayList<IptcBlock>()
				)),
				ExifWrapper.createEXIFMetadata()
		);
	}

	private static TiffImageMetadata createEXIFMetadata() {
		return new TiffImageMetadata(new TiffContents(
				new TiffHeader(ByteOrder.BIG_ENDIAN, 42, 0),
				new ArrayList<TiffDirectory>()
		));
	}

	public boolean validate(boolean prefixFilename) {
		boolean valid = true;

		String prefix = "";
		if (prefixFilename && this.imageFile != null) {
			prefix = this.imageFile.getName() + ": ";
		}

		String licence = EAtlasField.LICENCE.getValue(this);
		String reason = EAtlasField.REASON_NOT_CC.getValue(this);
		String attribution = EAtlasField.ATTRIBUTION.getValue(this);
		String photographers = EAtlasField.PHOTOGRAPHERS.getValue(this);

		if (StringUtils.isEmpty(licence) || licence.equals(EAtlasField.COPYRIGHT_KEY)) {
			if (StringUtils.isEmpty(reason)) {
				FXUtils.getNotificationWindow().showError(prefix + "You must specify a reason for not choosing Creative Commons licence.");
				valid = false;
			}
		}

		// No attribution nor photographer. Show a warning but save the file anyway.
		if (StringUtils.isEmpty(StringUtils.trim(attribution)) &&
				StringUtils.isEmpty(StringUtils.trim(photographers))) {
			FXUtils.getNotificationWindow().showWarning(prefix + "Note that it is strongly recommended to specified the attribution for the photo, or at lease the photographer.");
		}

		// Check the EXIF file size to be sure it doesn't go over the physical limit.
		try {
			if (this.getExifSize() > APPN_BYTE_SIZE_LIMIT || this.getIptcSize() > APPN_BYTE_SIZE_LIMIT) {
				// This error is very unlikely to occurred, the character limit is quite high. I don't expect anyone to write an image description of over 32 000 characters.
				FXUtils.getNotificationWindow().showError(prefix + "The amount of information exceed the the 64 kB storage limit of the image metadata. Try reducing the length of the description.");
				valid = false;
			}
		} catch(UnsupportedEncodingException ex) {
			FXUtils.getNotificationWindow().showError(prefix + "Exception occurred while analysing the metadata size. The file metadata may not have been saved properly.");
			LOGGER.log(Level.SEVERE, "Exception occurred while analysing the metadata size.", ex);
		}

		return valid;
	}

	public static boolean preValidateGPS(String gpsLon, String gpsLat, String gpsRadius) {
		boolean valid = true;
		gpsLon = StringUtils.trim(gpsLon);
		gpsLat = StringUtils.trim(gpsLat);
		gpsRadius = StringUtils.trim(gpsRadius);

		// Check if the 3 values are numbers
		if (!StringUtils.isEmpty(gpsLon) && !StringUtils.isNumeric(gpsLon)) {
			FXUtils.getNotificationWindow().showError("Invalid longitude. You must specified a decimal value between -180 and 180.");
			valid = false;
		}
		if (!StringUtils.isEmpty(gpsLat) && !StringUtils.isNumeric(gpsLat)) {
			FXUtils.getNotificationWindow().showError("Invalid latitude. You must specified a decimal value between -90 and 90.");
			valid = false;
		}
		if (!StringUtils.isEmpty(gpsRadius) && !StringUtils.isNumeric(gpsRadius)) {
			FXUtils.getNotificationWindow().showError("Invalid radius. You must specified a decimal value between in km.");
			valid = false;
		}

		// If we only have numbers, check integrity
		if (valid) {
			if ((!StringUtils.isEmpty(gpsLon) && StringUtils.isEmpty(gpsLat)) || (StringUtils.isEmpty(gpsLon) && !StringUtils.isEmpty(gpsLat))) {
				FXUtils.getNotificationWindow().showError("Incomplete GPS localisation. You can not specify only the longitude or latitude.");
				valid = false;
			}
		}

		return valid;
	}

	public boolean save() throws Exception {
		if (!this.imageFile.canWrite()) {
			FXUtils.getNotificationWindow().showError("The file ["+this.imageFile.getAbsolutePath()+"] is readonly.");
			LOGGER.log(Level.WARNING, "The file ["+this.imageFile.getAbsolutePath()+"] is readonly.");
			throw new IOException("The image file is not writable.");
		}

		return this.saveAs(this.imageFile);
	}

	public boolean saveAs(File destinationFile) throws Exception {
		boolean succeed = true;

		if (destinationFile == null) {
			FXUtils.getNotificationWindow().showError("The file ["+this.imageFile.getAbsolutePath()+"] can not be saved to an unspecified location.");
			LOGGER.log(Level.WARNING, "The file ["+this.imageFile.getAbsolutePath()+"] can not be saved to an unspecified location.");
			throw new IOException("The destination file is null.");
		}

		File tmpExifFile = null;
		File tmpIptcFile = null;

		Exception exception = null;
		try {
			// NOTE: When the metadata is close to the size limit, sometime it corrupt the image without
			// throwing any exception. The stability of this process rely on the 2 APPN (EXIF and IPTC).
			// If both pass without throwing exception, there is good chance that the image has not been
			// corrupted.
			// TODO: Add a post save test to check if the image is corrupted
			tmpExifFile = File.createTempFile("EXIF_", ".jpg");
			tmpIptcFile = File.createTempFile("IPTC_", ".jpg");
			try {
				ExifWrapper.saveEXIF(this.imageFile, tmpExifFile, this.jpegMetadata, this.modifiedExif, this.gps);
			} catch (Exception ex) {
				exception = ex;
				succeed = false;
			}

			if (succeed) {
				try {
					ExifWrapper.saveIPTC(tmpExifFile, tmpIptcFile, this.jpegMetadata, this.modifiedIptc);
				} catch (Exception ex) {
					exception = ex;
					succeed = false;
				}
			}

			// Copy the file to the destination only if nothing went wrong.
			if (succeed) {
				Files.copy(tmpIptcFile.toPath(), destinationFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
			}
		} finally {
			// Clean-up
			if (tmpExifFile != null && tmpExifFile.exists()) {
				tmpExifFile.delete();
			}
			if (tmpIptcFile != null && tmpIptcFile.exists()) {
				tmpIptcFile.delete();
			}
		}

		if (exception != null) {
			if (this.getExifSize() > WARNING_LIMIT_BYTE_SIZE || this.getIptcSize() > WARNING_LIMIT_BYTE_SIZE) {
				FXUtils.getNotificationWindow().showError("Could not save the metadata for the file ["+this.imageFile.getAbsolutePath()+"]. " +
						"The amount of information may have exceeded the 64 kB storage limit of the image metadata. Try reducing the length of the description.");
				succeed = false;
			} else {
				throw exception;
			}
		}

		return succeed;
	}

	private static void saveEXIF(File sourceFile, File destinationFile, JpegImageMetadata jpegMetadata, Map<TagInfo, String> modifiedExif, Double[] gps) throws ImageWriteException, IOException, ImageReadException {
		// Set the EXIF values
		if (jpegMetadata == null) {
			jpegMetadata = createJPEGMetadata();
		}
		TiffImageMetadata exif = jpegMetadata.getExif();
		if (exif == null) {
			exif = createEXIFMetadata();
		}
		TiffOutputSet outputSet = exif.getOutputSet();

		if (outputSet != null) {
			if (modifiedExif != null && !modifiedExif.isEmpty()) {
				for (Map.Entry<TagInfo, String> modifiedEntry : modifiedExif.entrySet()) {
					TagInfo tag = modifiedEntry.getKey();
					String value = modifiedEntry.getValue();
					TiffOutputDirectory directory = null;
					switch(tag.directoryType) {
						case TIFF_DIRECTORY_IFD0:
							// TIFF_DIRECTORY_ROOT is an alias for TIFF_DIRECTORY_IFD0
							directory = outputSet.getOrCreateRootDirectory();
							break;
						case EXIF_DIRECTORY_EXIF_IFD:
							directory = outputSet.getOrCreateExifDirectory();
							break;
						case EXIF_DIRECTORY_GPS:
							// Most of GPS (lon, lat) is managed bellow
							// This is only for the radius (GPS horizontal positioning error)
							directory = outputSet.getOrCreateGPSDirectory();
							break;
						default:
							FXUtils.getNotificationWindow().showWarning("The file ["+sourceFile.getAbsolutePath()+"] contains an unsupported tag directory type: " + tag.directoryType.name());
							LOGGER.log(Level.WARNING, "The file ["+sourceFile.getAbsolutePath()+"] contains an unsupported tag directory type: " + tag.directoryType.name());
							break;
					}

					if (directory != null) {
						directory.removeField(tag);
						if (tag instanceof TagInfoAscii) {
							String asciiValue = null;
							if (!StringUtils.isEmpty(value)) {
								asciiValue = UnicodeUtils.toASCII(value);
							}
							if (!StringUtils.isEmpty(asciiValue)) {
								directory.add((TagInfoAscii)tag, asciiValue);
							} else {
								directory.removeField(tag);
							}
						} else if (tag instanceof TagInfoRational) {
							RationalNumber number = null;
							if (!StringUtils.isEmpty(value)) {
								Double doubleValue = Double.valueOf(value);
								if (doubleValue != null) {
									number = RationalNumber.valueOf(doubleValue);
								} else {
									FXUtils.getNotificationWindow().showWarning("The file ["+sourceFile.getAbsolutePath()+"] has an invalid GPS radius value of: " + value);
								}
							}
							if (number != null) {
								directory.add((TagInfoRational)tag, number);
							} else {
								directory.removeField(tag);
							}
						} else if (tag instanceof TagInfoXpString) {
							if (!StringUtils.isEmpty(value)) {
								// XP support Unicode characters
								directory.add((TagInfoXpString)tag, value);
							} else {
								directory.removeField(tag);
							}
						} else {
							FXUtils.getNotificationWindow().showWarning("The file ["+sourceFile.getAbsolutePath()+"] contains an unsupported tag type: " + tag.getClass().getSimpleName());
							LOGGER.log(Level.WARNING, "The file ["+sourceFile.getAbsolutePath()+"] contains an unsupported tag type: " + tag.getClass().getSimpleName());
						}
					}
				}
			}

			// Set the GPS values
			// NOTE: Do not create the GPS Directory if it do not exists and no GPS values are known.
			//     I.E. Use "outputSet.getGPSDirectory()" instead of "outputSet.getOrCreateGPSDirectory()" here.
			if (gps != null && gps[LONGITUDE_IND] != null && gps[LATITUDE_IND] != null) {
				outputSet.setGPSInDegrees(gps[LONGITUDE_IND], gps[LATITUDE_IND]);
			} else {
				final TiffOutputDirectory gpsDirectory = outputSet.getGPSDirectory();
				if (gpsDirectory != null) {
					gpsDirectory.removeField(GpsTagConstants.GPS_TAG_GPS_LONGITUDE);
					gpsDirectory.removeField(GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF);
					gpsDirectory.removeField(GpsTagConstants.GPS_TAG_GPS_LATITUDE);
					gpsDirectory.removeField(GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF);
				}
			}

			// Save EXIF
			InputStream is = null;
			OutputStream os = null;
			try {
				is = new FileInputStream(sourceFile);
				os = new BufferedOutputStream(new FileOutputStream(destinationFile));
				// IMPORTANT: updateExifMetadataLossless cause the library to save data without knowing about it, corrupting
				//     the EXIF headers. updateExifMetadataLossy does a much better job and only unusable data is lost anyway.
				// NOTE from Java doc
				// updateExifMetadataLossless: Note that this uses the "Lossless" approach - in order to preserve data embedded in the EXIF
				//     segment that it can't parse (such as Maker Notes), this algorithm avoids overwriting
				//     any part of the original segment that it couldn't parse.  This can cause the EXIF segment to
				//     grow with each update, which is a serious issue, since all EXIF data must fit in a single APP1
				//     segment of the Jpeg image.
				// updateExifMetadataLossy: Note that this uses the "Lossy" approach - the algorithm overwrites the entire EXIF segment,
				//     ignoring the possibility that it may be discarding data it couldn't parse (such as Maker Notes).
				new ExifRewriter().updateExifMetadataLossy(is, os, outputSet);
			} finally {
				if (os != null) {
					try { os.flush(); } catch(Exception ex) {
						FXUtils.getNotificationWindow().showWarning("Can not flush the image OutputStream ["+destinationFile.getAbsolutePath()+"]");
						LOGGER.log(Level.WARNING, "Can not flush the image OutputStream ["+destinationFile.getAbsolutePath()+"]", ex);
					}
					try { os.close(); } catch(Exception ex) {
						FXUtils.getNotificationWindow().showWarning("Can not close the image OutputStream ["+destinationFile.getAbsolutePath()+"]");
						LOGGER.log(Level.WARNING, "Can not close the image OutputStream ["+destinationFile.getAbsolutePath()+"]", ex);
					}
				}
				if (is != null) {
					try { is.close(); } catch(Exception ex) {
						FXUtils.getNotificationWindow().showWarning("Can not close the image InputStream ["+sourceFile.getAbsolutePath()+"]");
						LOGGER.log(Level.WARNING, "Can not close the image InputStream ["+sourceFile.getAbsolutePath()+"]", ex);
					}
				}
			}
		}
	}

	private static void saveIPTC(File sourceFile, File destinationFile, JpegImageMetadata jpegMetadata, Map<IptcType, String> modifiedIptc) throws IOException, ImageWriteException, ImageReadException {
		// Set the IPTC values
		PhotoshopApp13Data photoshopApp13Data = null;
		JpegPhotoshopMetadata photoshop = jpegMetadata.getPhotoshop();
		if (photoshop != null && photoshop.photoshopApp13Data != null) {
			photoshopApp13Data = photoshop.photoshopApp13Data;
		}

		// Retrieve actual IPTC values
		List<IptcBlock> newBlocks = photoshopApp13Data == null ? new ArrayList<IptcBlock>() : photoshopApp13Data.getNonIptcBlocks();
		List<IptcRecord> oldRecords = photoshopApp13Data == null ? new ArrayList<IptcRecord>() : photoshopApp13Data.getRecords();
		// Create a new set of IPTC values, without the one that we want to modify
		List<IptcRecord> newRecords = new ArrayList<IptcRecord>();
		for (IptcRecord record : oldRecords) {
			// Only add it if it hasn't been modified
			boolean modified = false;
			if (modifiedIptc != null && !modifiedIptc.isEmpty()) {
				for (IptcType modifiedType : modifiedIptc.keySet()) {
					if (record.iptcType.equals(modifiedType)) {
						modified = true;
						break;
					}
				}
			}
			if (!modified) {
				newRecords.add(record);
			}
		}
		// Add the IPTC value that we want to modify
		if (modifiedIptc != null && !modifiedIptc.isEmpty()) {
			for (IptcType modifiedType : modifiedIptc.keySet()) {
				String value = StringUtils.trim(modifiedIptc.get(modifiedType));
				if (!StringUtils.isEmpty(value)) {
					newRecords.add(new IptcRecord(modifiedType, value));
				}
			}
		}
		PhotoshopApp13Data newPhotoshopApp13Data = new PhotoshopApp13Data(newRecords, newBlocks);

		// Save IPTC
		InputStream is = null;
		OutputStream os = null;
		try {
			is = new FileInputStream(sourceFile);
			os = new BufferedOutputStream(new FileOutputStream(destinationFile));
			new JpegIptcRewriter().writeIPTC(is, os, newPhotoshopApp13Data);
		} finally {
			if (os != null) {
				try { os.flush(); } catch(Exception ex) {
					FXUtils.getNotificationWindow().showWarning("Can not flush the image OutputStream ["+destinationFile.getAbsolutePath()+"]");
					LOGGER.log(Level.WARNING, "Can not flush the image OutputStream ["+destinationFile.getAbsolutePath()+"]", ex);
				}
				try { os.close(); } catch(Exception ex) {
					FXUtils.getNotificationWindow().showWarning("Can not close the image OutputStream ["+destinationFile.getAbsolutePath()+"]");
					LOGGER.log(Level.WARNING, "Can not close the image OutputStream ["+destinationFile.getAbsolutePath()+"]", ex);
				}
			}
			if (is != null) {
				try { is.close(); } catch(Exception ex) {
					FXUtils.getNotificationWindow().showWarning("Can not close the image InputStream ["+sourceFile.getAbsolutePath()+"]");
					LOGGER.log(Level.WARNING, "Can not close the image InputStream ["+sourceFile.getAbsolutePath()+"]", ex);
				}
			}
		}
	}

	public String getExifStringValue(TagInfo tag) {
		Object value = this.getExifValue(tag);
		if (value == null) {
			return null;
		}
		return StringUtils.trim(value.toString());
	}

	/**
	 * NOTE This method only works with rational numbers. It doesn't work with GPS values (which are arrays of rational numbers)
	 * @param tag The rational tag. Example: GpsTagConstants.GPS_TAG_GPS_LONGITUDE
	 * @return The numerical value represented by the rational number (for the specified tag) found in the EXIF object.
	 */
	public Double getDoubleValue(TagInfoRational tag) {
		if (tag == null) {
			return null;
		}
		Object value = this.getExifValue(tag);
		if (value == null) {
			return null;
		}
		if (value instanceof RationalNumber) {
			return ((RationalNumber) value).doubleValue();
		}
		// The value may have been saved in the modifiedExif map (as a String rather then a RationalNumber)
		if (value instanceof String) {
			return Double.valueOf((String)value);
		}
		return null;
	}

	public void setDoubleValue(TagInfoRational tag, Double value) {
		if (tag != null) {
			this.modifiedExif.put(tag, value == null ? null : value.toString());
		}
	}

	/**
	 * Load GPS values from the EXIF object, using Apache Imagery library.
	 * If the GPS values are broken, show an warning message and try to load
	 * the raw GPS values using getRawGPSValue().
	 * @return GPS values in an array. Array indexes are LONGITUDE_IND and LATITUDE_IND.
	 */
	public Double[] getGPSValue() {
		if (this.gps != null) {
			return this.gps;
		}
		if (this.jpegMetadata != null) {
			TiffImageMetadata exif = this.jpegMetadata.getExif();
			if (exif != null) {
				try {
					TiffImageMetadata.GPSInfo gpsInfo = exif.getGPS();
					if (gpsInfo != null) {
						Double[] gpsArr = new Double[2];
						gpsArr[LONGITUDE_IND] = gpsInfo.getLongitudeAsDegreesEast();
						gpsArr[LATITUDE_IND] = gpsInfo.getLatitudeAsDegreesNorth();
						return gpsArr;
					} else {
						// getGPS() may return null when the GPS values are incomplete.
						Double[] gpsArr = this.getBrokenGPSValue();
						if (gpsArr != null) {
							FXUtils.getNotificationWindow().showWarning("Error getting GPS value for file ["+this.imageFile.getAbsolutePath()+"]. The value found are not reliable.");
							LOGGER.log(Level.FINEST, "Error getting GPS value for file ["+this.imageFile.getAbsolutePath()+"]. The returned GPS values was NULL.");
							return gpsArr;
						}
					}
				} catch(ImageReadException ex) {
					// This is exception happen when the GPS value is not valid (ex: LTMP images)
					FXUtils.getNotificationWindow().showWarning("Error getting GPS value for file ["+this.imageFile.getAbsolutePath()+"]. The value found are not reliable.");
					LOGGER.log(Level.FINEST, "Error getting GPS value for file ["+this.imageFile.getAbsolutePath()+"]", ex);

					// Try to get the raw value
					try {
						Double[] gpsArr = this.getBrokenGPSValue();
						if (gpsArr != null) {
							return gpsArr;
						}
					} catch(Exception ex2) {
						LOGGER.log(Level.FINEST, "Error getting RAW GPS value for file ["+this.imageFile.getAbsolutePath()+"]", ex2);
					}
				}
			}
		}

		return null;
	}


	/**
	 * Load the GPS directly from the EXIF object, without using the API.
	 * This method is more likely to return a value, but that value is less
	 * likely to be reliable since it loads the values whatever how rubbish it is.
	 * @return
	 * NOTE: This method is highly inspired from TiffImageMetadata.getGPS()
	 *     https://apache.googlesource.com/sanselan/+/6b6fcdd6cd64d8700c14395b89425acd735159d0/src/main/java/org/apache/commons/sanselan/formats/tiff/TiffImageMetadata.java
	 */
	private Double[] getBrokenGPSValue() throws ImageReadException {
		TiffImageMetadata exif = this.jpegMetadata.getExif();
		if (exif == null) {
			// There is really nothing that can be done in that case...
			return null;
		}

		TiffDirectory gpsDirectory = exif.findDirectory(TiffDirectoryConstants.DIRECTORY_TYPE_GPS);
		if (null == gpsDirectory) {
			// There is really nothing that can be done in that case...
			return null;
		}

		// more specific example of how to access GPS values.
		// NOTE: Any of those field might be null. We are working with broken values.
		TiffField latitudeRefField = gpsDirectory.findField(GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF);
		TiffField latitudeField = gpsDirectory.findField(GpsTagConstants.GPS_TAG_GPS_LATITUDE);
		TiffField longitudeRefField = gpsDirectory.findField(GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF);
		TiffField longitudeField = gpsDirectory.findField(GpsTagConstants.GPS_TAG_GPS_LONGITUDE);

		// all of these values are strings.
		// NOTE: Any of those field might be null. We are working with broken values.
		String latitudeRef = (latitudeRefField == null) ? null : latitudeRefField.getStringValue();
		RationalNumber latitude[] = (latitudeField == null) ? null : (RationalNumber[]) latitudeField.getValue();
		String longitudeRef = (longitudeRefField == null) ? null : longitudeRefField.getStringValue();
		RationalNumber longitude[] = (longitudeField == null) ? null : (RationalNumber[]) longitudeField.getValue();

		// NOTE: Any of those field might be null. We are working with broken values.
		RationalNumber latitudeDegrees = (latitude == null || latitude.length < 1) ? null : latitude[0];
		RationalNumber latitudeMinutes = (latitude == null || latitude.length < 2) ? null : latitude[1];
		RationalNumber latitudeSeconds = (latitude == null || latitude.length < 3) ? null : latitude[2];
		RationalNumber longitudeDegrees = (longitude == null || longitude.length < 1) ? null : longitude[0];
		RationalNumber longitudeMinutes = (longitude == null || longitude.length < 2) ? null : longitude[1];
		RationalNumber longitudeSeconds = (longitude == null || longitude.length < 3) ? null : longitude[2];

		Double[] gpsArr = new Double[2];

		// Try to get the most accurate value, even when the coordinate are pretty messed up...
		// NOTE: Any of those field might be null. We are working with broken values.
		if (longitudeDegrees != null || longitudeMinutes != null || longitudeSeconds != null) {
			gpsArr[LONGITUDE_IND] = 0.0;
			if (longitudeDegrees != null) {
				gpsArr[LONGITUDE_IND] += longitudeDegrees.doubleValue();
			}
			if (longitudeMinutes != null) {
				gpsArr[LONGITUDE_IND] += (longitudeMinutes.doubleValue() / 60.0);
			}
			if (longitudeSeconds != null) {
				gpsArr[LONGITUDE_IND] += (longitudeSeconds.doubleValue() / 3600.0);
			}
			if (longitudeRef != null) {
				// Should be "W", but the GPS value might be really broken.
				if (longitudeRef.equalsIgnoreCase("w") || longitudeRef.equalsIgnoreCase("s")) {
					gpsArr[LONGITUDE_IND] = -gpsArr[LONGITUDE_IND];
				}
			}
		}
		if (latitudeDegrees != null || latitudeMinutes != null || latitudeSeconds != null) {
			gpsArr[LATITUDE_IND] = 0.0;
			if (latitudeDegrees != null) {
				gpsArr[LATITUDE_IND] += latitudeDegrees.doubleValue();
			}
			if (latitudeMinutes != null) {
				gpsArr[LATITUDE_IND] += (latitudeMinutes.doubleValue() / 60.0);
			}
			if (latitudeSeconds != null) {
				gpsArr[LATITUDE_IND] += (latitudeSeconds.doubleValue() / 3600.0);
			}
			if (latitudeRef != null) {
				// Should be "S", but the GPS value might be really broken.
				if (latitudeRef.equalsIgnoreCase("w") || latitudeRef.equalsIgnoreCase("s")) {
					gpsArr[LATITUDE_IND] = -gpsArr[LATITUDE_IND];
				}
			}
		}

		if (gpsArr[LONGITUDE_IND] != null || gpsArr[LATITUDE_IND] != null) {
			return gpsArr;
		}

		return null;
	}

	public void setGPSValue(Double lon, Double lat) {
		this.setGPSValue(new Double[]{lon, lat});
	}
	public void setGPSValue(Double[] gps) {
		this.gps = gps;
	}

	public Object getExifValue(TagInfo tag) {
		if (tag == null || this.jpegMetadata == null) {
			return null;
		}
		if (this.modifiedExif.containsKey(tag)) {
			return this.modifiedExif.get(tag);
		}
		TiffImageMetadata exif = this.jpegMetadata.getExif();
		if (exif != null) {
			try {
				return exif.getFieldValue(tag);
			} catch(ImageReadException ex) {
				// This is unlikely to happen
				// If a value can not be read, just consider it as been missing.
				FXUtils.getNotificationWindow().showWarning("Error getting EXIF value for file ["+this.imageFile.getAbsolutePath()+"]");
				LOGGER.log(Level.FINEST, "Error getting EXIF value for file ["+this.imageFile.getAbsolutePath()+"]", ex);
			}
		}
		return null;
	}

	public void setExifValue(TagInfo tag, String value) {
		if (tag != null) {
			this.modifiedExif.put(tag, value);
		}
	}

	public String getIptcValue(IptcType type) {
		if (type == null || this.jpegMetadata == null) {
			return null;
		}
		if (this.modifiedIptc.containsKey(type)) {
			return this.modifiedIptc.get(type);
		}
		// There is no method to find a IPTC value. We have to do a sequential lookup.
		JpegPhotoshopMetadata photoshop = this.jpegMetadata.getPhotoshop();
		if (photoshop != null && photoshop.photoshopApp13Data != null) {
			for (IptcRecord rec : photoshop.photoshopApp13Data.getRecords()) {
				if (rec.iptcType.equals(type)) {
					return StringUtils.trim(rec.getValue());
				}
			}
		}
		return null;
	}

	public void setIptcValue(IptcType type, String value) {
		if (type != null) {
			this.modifiedIptc.put(type, UnicodeUtils.toISO_8859_1(value));
		}
	}

	public static class ExifWrapperComparator implements Comparator<ExifWrapper> {
		private static final StringUtils.NaturalOrderComparator NATURAL_ORDER_COMPARATOR = new StringUtils.NaturalOrderComparator();

		@Override
		public int compare(ExifWrapper o1, ExifWrapper o2) {
			// same instance or both null
			if (o1 == o2) { return 0; }
			// nulls at the end
			if (o1 == null) { return -1; }
			if (o2 == null) { return 1; }

			File f1 = o1.getImageFile();
			File f2 = o2.getImageFile();
			// nulls at the end
			if (f1 == null) { return -1; }
			if (f2 == null) { return 1; }

			// Order using the natural order (file1.jpg, file2.jpg... ,file9.jpg, file10.jpg, etc)
			return NATURAL_ORDER_COMPARATOR.compare(f1.getAbsolutePath(), f2.getAbsolutePath());
		}
	}
}
